#!/usr/bin/env python3

# Copyright (c) 2020-2021 Fpemud <fpemud@sina.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.


import os
import abc
import json
import glob
import time
import pathlib
import requests
import htmllistparse
import urllib.parse
import urllib.request
import urllib.error
from ._util import Util, AvahiServiceBrowser
from ._const import BackendType, SiteUtil
from ._errors import ConfigError, BackendError
from ._config import Config


class Backend(abc.ABC):

    @staticmethod
    def new(cfg, backend_name, backend_cfg):
        param = backend_cfg.get("param", {})
        if backend_cfg["type"] == BackendType.LOCAL_FILE.value:
            return BackendDataFile(cfg, backend_name, param)
        elif backend_cfg["type"] == BackendType.LOCAL_DIR.value:
            return BackendDataDir(cfg, backend_name, param)
        elif backend_cfg["type"] == BackendType.HTTP_DIR.value:
            return BackendHttpDir(cfg, backend_name, param)
        elif backend_cfg["type"] == BackendType.MIRRORS_API.value:
            return BackendMirrorsApi(cfg, backend_name, param)
        elif backend_cfg["type"] == BackendType.ZEROCONF_MIRRORS_API.value:
            return BackendZeroConfMirrorsApi(cfg, backend_name, param)
        else:
            assert False

    def __init__(self, cfg, backend_name, backend_type, is_local, param):
        self._cfg = None
        if True:
            assert isinstance(cfg, Config)
            self._cfg = cfg

        self._name = None
        if True:
            if len(backend_name) == 0:
                raise ConfigError('backend name is empty')
            if len(backend_name) > 64:
                raise ConfigError('backend name "%s" is too long' % (backend_name))
            self._name = backend_name

        self._type = None
        if True:
            assert isinstance(backend_type, BackendType)
            self._type = backend_type

        self._bLocal = is_local
        if self._bLocal:
            if "aging-time" in param:
                raise ConfigError('"aging-time" should not exist in config for backend "%s"' % (self._name))
        else:
            self._agingTime = param.get("aging-time", None)

    @property
    def backend_name(self):
        return self._name

    @property
    def backend_type(self):
        return self._type

    def is_local(self):
        return self._bLocal

    def get_mirror_names(self):
        return self._getMirrorNamesImpl()

    def get_data_and_aging_time(self, mirror_name_list):
        data, agingTime = self._getDataAndAgingTimeImpl(mirror_name_list)

        for name, v in data.items():
            v.setdefault("sources", [])
            v.setdefault("mirrors", [])

            for s in v["sources"]:
                try:
                    SiteUtil.check_and_fill_object(s, True)
                except ValueError as e:
                    raise ValueError("invalid %s source from backend %s (type %s), %s" % (name, self._name, self._type, e))

            for m in v["mirrors"]:
                try:
                    SiteUtil.check_and_fill_object(m, False)
                except ValueError as e:
                    raise ValueError("invalid %s mirror from backend %s (type %s), %s" % (name, self._name, self._type, e))

        if not self._bLocal:
            assert agingTime is not None
            if self._agingTime is not None:
                # override aging time if specifed
                agingTime = self._agingTime
        else:
            assert agingTime is None

        # note:
        # there may be some mirror name not in ret
        # current backend may not support all mirrors requested
        return (data, agingTime)

    @abc.abstractmethod
    def _getMirrorNamesImpl(self):
        pass

    @abc.abstractmethod
    def _getDataAndAgingTimeImpl(self, mirror_name_list):
        pass


class BackendDataFile(Backend):

    # file content example:
    #   apache https://apache.lauf-forum.at/ https://apache.mirror.iphh.net/
    #   cpan   https://cpan.metacpan.org https://www.cpan.org
    #   cran   https://cran.r-project.org https://cran.us.r-project.org
    #   debian https://deb.debian.org/debian/ http://ftp.au.debian.org/debian/ http://ftp.at.debian.org/debian/

    def __init__(self, cfg, backend_name, backend_cfg):
        if "path" not in backend_cfg:
            raise _getConfigError(backend_name, BackendType.LOCAL_FILE, "no", "path")
        if not os.path.isabs(backend_cfg["path"]):
            raise _getConfigError(backend_name, BackendType.LOCAL_FILE, "invalid", "path")
        self._path = backend_cfg["path"]
        super().__init__(cfg, backend_name, BackendType.LOCAL_FILE, True, backend_cfg)

    def _getMirrorNamesImpl(self):
        data, _ = self._getDataAndAgingTimeImpl()
        return set(data.keys())

    def _getDataAndAgingTimeImpl(self, mirror_name_list):
        try:
            data = dict()
            for line in pathlib.Path(self._path).read_text().split("\n"):
                line = line.strip()
                if line == "" or line.startswith("#"):
                    continue
                items = line.replace("\t", " ").split(" ")
                name = items[0]
                if mirror_name_list is None or name in mirror_name_list:
                    if name in data:
                        raise BackendError(self.__class__, "duplicate name \"%s\" in \"%s\"" % (name, self._path))
                    data[name] = {
                        "mirrors": [x for x in items[1:] if x != ""],
                    }
            return (data, None)
        except FileNotFoundError:
            return ({}, None)
        except NotADirectoryError:
            # parent directory of the target file does not exist
            return ({}, None)
        except IsADirectoryError:
            raise BackendError(self.__class__, '"%s" is a directory' % (self._path))


class BackendDataDir(Backend):

    # file content example:
    #   {
    #       "info": {
    #           XXXX
    #       }
    #       "data": {
    #           "alpine": {
    #               "sources": [
    #                   {
    #                       "url": "https://dl-cdn.alpinelinux.org/"
    #                   }
    #               ],
    #               "mirrors": [
    #                   {
    #                       "name": "XXX",
    #                       "description": "XXX",
    #                       "url": "https://dl-cdn.alpinelinux.org/",
    #                       "address-families": ["ip", "ip6"]
    #                   }
    #               ]
    #           }
    #       }
    #   }

    def __init__(self, cfg, backend_name, backend_cfg):
        if "path" not in backend_cfg:
            raise _getConfigError(backend_name, BackendType.LOCAL_DIR, "no", "path")
        if not os.path.isabs(backend_cfg["path"]):
            raise _getConfigError(backend_name, BackendType.LOCAL_DIR, "invalid", "path")
        self._path = backend_cfg["path"]
        super().__init__(cfg, backend_name, BackendType.LOCAL_DIR, True, backend_cfg)

    def _getMirrorNamesImpl(self):
        return set(self._getFnMap().keys())

    def _getDataAndAgingTimeImpl(self, mirror_name_list):
        data = dict()
        for name, fullfn in self._getFnMap(mirror_name_list).items():
            with open(fullfn) as f:
                t = json.load(f)
                data.setdefault(name, {"sources": [], "mirrors": []})
                if "sources" in t:
                    data[name]["sources"] += t.get("sources", [])
                    del t["sources"]
                if "mirrors" in t:
                    data[name]["mirrors"] += t.get("mirrors", [])
                    del t["mirrors"]
                if len(t) > 0:
                    raise BackendError(self.__class__, "redundant content found in \"%s\"" % (fullfn))
        return (data, None)

    def _getFnMap(self, mirror_name_list):
        if not os.path.exists(self._path):
            return dict()

        if not os.path.isdir(self._path):
            raise BackendError(self.__class__, "\"%s\" is not a directory" % (self._path))

        fnMap = dict()

        if mirror_name_list is None:
            for fullfn in glob.glob(os.path.join(self._path, "*.json")):
                name = os.path.basename(fullfn)[:len(".json")*-1]
                fnMap[name] = fullfn
        else:
            for name in mirror_name_list:
                fullfn = os.path.join(self._path, name + ".json")
                if os.path.exists(fullfn):
                    fnMap[name] = fullfn

        for fullfn in fnMap.values():
            if not os.path.isfile(fullfn):
                raise BackendError(self.__class__, "\"%s\" is not a file" % (fullfn))

        return fnMap


class BackendHttpDir(Backend):

    def __init__(self, cfg, backend_name, backend_cfg):
        self._cfg = cfg
        if "url" in backend_cfg:
            if "urls" in backend_cfg:
                raise _getConfigError(backend_name, BackendType.HTTP_DIR, "no", "urls")
            if not (backend_cfg["url"].startswith("http://") or backend_cfg["url"].startswith("https://")):
                raise _getConfigError(backend_name, BackendType.HTTP_DIR, "invalid", "url")
            self._url = backend_cfg["url"]
        elif "urls" in backend_cfg:
            if "url" in backend_cfg:
                raise _getConfigError(backend_name, BackendType.HTTP_DIR, "no", "url")
            if not all([x.startswith("http://") or x.startswith("https://") for x in backend_cfg["urls"]]):
                raise _getConfigError(backend_name, BackendType.HTTP_DIR, "invalid", "urls")
            self._url = backend_cfg["urls"][0]                                                                        # FIXME
        else:
            raise _getConfigError(backend_name, BackendType.HTTP_DIR, "no", "url")

        super().__init__(cfg, backend_name, BackendType.HTTP_DIR, False, backend_cfg)

    def _getMirrorNamesImpl(self):
        data, _ = self._getDataAndAgingTimeImpl(None, name_only=True)
        return set(data.keys())

    def _getDataAndAgingTimeImpl(self, mirror_name_list, name_only=False):
        data = dict()
        for i in Util.rangeMayInfinite(0, self._cfg.get_retry_count()):
            lastError = None
            try:
                for entry in htmllistparse.fetch_listing(self._url, timeout=self._cfg.get_update_timeout())[1]:
                    if entry.name.endswith(".json"):
                        name = entry.name[:len(".json")*-1]
                        if mirror_name_list is None or name in mirror_name_list:
                            data[name] = None
                break
            except urllib.error.URLError as e:
                time.sleep(self._cfg.get_retry_wait())
                lastError = e
            except requests.exceptions.RequestException as e:
                time.sleep(self._cfg.get_retry_wait())
                lastError = e
        if lastError is not None:
            raise BackendError(self.__class__, "failed to access URL \"%s\", %s" % (self._url, lastError))

        if name_only:
            return (data, 0)

        for name in data:
            fn = name + ".json"
            url = os.path.join(self._url, fn)
            for i in Util.rangeMayInfinite(0, self._cfg.get_retry_count()):
                lastError = None
                try:
                    with urllib.request.urlopen(url, timeout=self._cfg.get_update_timeout()) as resp:
                        data[name] = json.load(resp)
                        data[name].setdefault("sources", [])
                        data[name].setdefault("mirrors", [])
                        if len(data[name]) > 2:
                            raise BackendError(self.__class__, "redundant content found in \"%s\"" % (url))
                    break
                except urllib.error.URLError as e:
                    time.sleep(self._cfg.get_retry_wait())
                    lastError = e
                except TimeoutError as e:
                    time.sleep(self._cfg.get_retry_wait())
                    lastError = e
            if lastError is not None:
                raise BackendError(self.__class__, "failed to access URL \"%s\", %s" % (url, lastError))

        return (data, 0)


class BackendMirrorsApi(Backend):

    # mirrors api result example:
    #   {
    #       "gentoo": {
    #           "access": {
    #               "web": {
    #                   "url": "http://{IP}/distfiles"
    #               }
    #           },
    #           "last-update-time": ""
    #       }
    #   }

    def __init__(self, cfg, backend_name, backend_cfg):
        if "url" not in backend_cfg:
            raise _getConfigError(backend_name, BackendType.MIRRORS_API, "no", "url")
        if not (backend_cfg["url"].startswith("http://") or backend_cfg["url"].startswith("https://")):
            raise _getConfigError(backend_name, BackendType.MIRRORS_API, "invalid", "url")

        self._cfg = cfg
        self._url = backend_cfg["url"]

        super().__init__(cfg, backend_name, BackendType.MIRRORS_API, False, backend_cfg)

    def _getMirrorNamesImpl(self):
        data, _ = self._getDataAndAgingTimeImpl(None)
        return set(data.keys())

    def _getDataAndAgingTimeImpl(self, mirror_name_list):
        data = dict()

        t = urllib.parse.urlparse(self._url)
        for i in Util.rangeMayInfinite(0, self._cfg.get_retry_count()):
            lastError = None
            try:
                with urllib.request.urlopen(self._url, timeout=self._cfg.get_update_timeout()) as resp:
                    for name, value in json.load(resp).items():
                        if mirror_name_list is not None and name not in mirror_name_list:
                            continue
                        if "access" not in value:
                            continue

                        mirrorList = []
                        for proto, info in value["access"].items():
                            if proto in ["web", "ftp", "rsync"]:
                                mirrorList.append({
                                    "url": info["url"].replace("{IP}", t.netloc),
                                    "role": "local-mirror",
                                })
                            else:
                                # unsupported protocol
                                pass

                        data.setdefault(name, {"mirrors": []})
                        data[name]["mirrors"] += mirrorList
                break
            except urllib.error.URLError as e:
                time.sleep(self._cfg.get_retry_wait())
                lastError = e
            except TimeoutError as e:
                time.sleep(self._cfg.get_retry_wait())
                lastError = e
        if lastError is not None:
            raise BackendError(self.__class__, "failed to access URL %s, %s" % (self._url, lastError))

        return (data, 0)


class BackendZeroConfMirrorsApi(Backend):

    # mirrors api result example:
    #   {
    #       "gentoo": {
    #           "access": {
    #               "web": {
    #                   "url": "http://{IP}/distfiles"
    #               }
    #           },
    #           "last-update-time": ""
    #       }
    #   }

    def __init__(self, cfg, backend_name, backend_cfg):
        self._cfg = cfg
        super().__init__(cfg, backend_name, BackendType.ZEROCONF_MIRRORS_API, False, backend_cfg)

    def _getMirrorNamesImpl(self):
        data, _ = self._getDataAndAgingTimeImpl(None)
        return set(data.keys())

    def _getDataAndAgingTimeImpl(self, mirror_name_list):
        data = dict()

        browser = AvahiServiceBrowser("_mirrors._tcp")
        browser.run()
        for name, addr, port in browser.get_result_list():
            url = "http://%s:%d/api/mirrors" % (addr, port)
            for i in Util.rangeMayInfinite(0, self._cfg.get_retry_count()):
                lastError = None
                try:
                    with urllib.request.urlopen(url, timeout=self._cfg.get_update_timeout()) as resp:
                        for name, value in json.load(resp).items():
                            if mirror_name_list is not None and name not in mirror_name_list:
                                continue
                            if "access" not in value:
                                continue

                            mirrorList = []
                            for proto, info in value["access"].items():
                                if proto in ["web", "ftp", "rsync"]:
                                    mirrorList.append({
                                        "url": info["url"].replace("{IP}", addr),
                                        "role": "local-mirror",
                                    })
                                else:
                                    # unsupported protocol
                                    pass

                            data.setdefault(name, {"mirrors": []})
                            data[name]["mirrors"] += mirrorList
                    break
                except urllib.error.URLError as e:
                    time.sleep(self._cfg.get_retry_wait())
                    lastError = e
                except TimeoutError as e:
                    time.sleep(self._cfg.get_retry_wait())
                    lastError = e
            if lastError is not None:
                raise BackendError(self.__class__, "failed to access URL %s, %s" % (url, lastError))

        return (data, 0)


def _getConfigError(backend_name, backend_type, error_type, property_name):
    return ConfigError('%s "%s" property for data source %s backend %s' % (error_type, property_name, backend_name, backend_type.value))
